...loading
2024-12-04
당분간의 포스팅에서는 리액트 클린코드에 대해 다뤄볼 것이다. 개발자는 기능하는 코드를 생산하는 것 이상으로 코드의 품질을 관리하는 것이 중요하다. 코드를 작성하다보면 주먹구구식으로 기능을 구현하는데 급급했던 경험들이 많다. 어떻게든 기능을 구현하려다보니, 내가 작성하는 코드들이 효율적으로 작성되고 있었는가에 대한 성찰이 부족했었다. 그리고 막상 기능을 구현하고 나니 귀찮음,,에 의해 코드 리펙토링에 큰 신경을 쓰지 않고 개발해왔다.
최근에 와서 드는 생각인데 애초에 코드 자체를 깨끗하게 작성하는 습관을 들이는 것이 중요한 것 같다. 그래서 클린코드에 대한 포스팅을 작성해보자고 마음먹는다. 이번 포스팅에서는 리액트에서 state 작성을 깔끔하게 하는 방법에 대해 다루고자 한다.
리액트 상태의 초기값은 컴포넌트가 렌더링 될 때 순간적으로 보여지는 값이다. 따라서 초기값을 적절하게 부여하는 것이 중요하다.
const [list ,setList] = useState(); const [count, setCount] = useState('0'); return( <div> <li> {list.map((e)=>(<ul>{e.title}</ul>))} // 에러 발생 </li> <button onClick={()=>{setCount((prev)=> prev++))}>+</button> // 에러 발생 </div> )
위의 코드처럼 리액트 프로그래밍을 하다보면 무심코 초기값을 설정해주지 않거나, 타입에 맞지 않게 작성하는 경우가 많다. 이러한 상황에서 어떠한 문제점이 발생할 수 있는지 살펴보자. 위 예시에서 list 상태값을 보면 초기값을 설정하지 않았기에 undefined가 할당된다. 따라서 컴포넌트가 렌더링 되는 순간 list를 매핑처리할 수 없는 에러가 발생한다. 이제 에러를 해결해야하는데 프로젝트가 복잡하고 해당 컴포넌트의 depth가 깊어진다면 어떻게 될까.
이러한 상황에서 에러를 주먹구구식으로 해결하다보면 list의 타입을 확인시키는 조건문을 추가하는 등의 실수가 발생할 수 있다. 결과적으로 복잡성이 늘어난다. 이는 초기값의 타입을 정확하게 입력하지 않았을 때도 마찬가지다. 따라서 아래의 코드처럼 처음부터 올바른 초기값을 할당함으로써 발생가능한 비용을 예방하는 것이 중요하다.
// Clean code const [list ,setList] = useState([]); const [count, setCount] = useState(0); return( <div> <li> {list.map((e)=>(<ul>{e.title}</ul>))} </li> <button onClick={()=>{setCount((prev)=> prev++))}>+</button> </div> )
불필요한 상태들을 굳이 만들 필요는 없다. 불필요한 상태들을 선언할수록 리액트의 렌더링 트리거가 증가하기 때문이다. 리액트를 프로그래밍하다보면 새로운 State를 선언하는 것이 문제를 쉽게 해결하는데 도움이 되고는 한다. 하지만 잘 생각해보면 굳이 상태값이 아니어도 되는 경우들이 있다.
리액트 컴포넌트는 렌더링 될 때 마다 재실행된다. 따라서 리액트 컴포넌트 내의 변수는 렌더링 마다 고유의 값을 가지는 계산된 값이 된다. 이러한 변수의 성질을 활용하면 상태값의 선언을 줄일 수 있다.
const [postList, setPostList] = useState(MOCK_DATA_SORTED_BY_DESC); const [newestSortedList, newestSortedList] = useState(MOCK_DATA_SORTED_BY_DESC); // 변경 후 저장될 리스트 useEffect(()=>{ const newestSortedList = postList.sort(.......); setNewestSortedList(newestSortedList); },[postList])
위의 코드는 내림차순 정렬된 데이터와 최신 정렬된 코드를 사용하는 컴포넌트다. 내림차순 정렬된 목업데이터를 통해 최신순으로 정렬된 리스트를 상태로 저장하고 있다. 얼핏 보기에는 나쁘지 않지만, 상태값이 추가될 필요가 없는 코드다.
// Clean code const [postList, setPostList] = useState(MOCK_DATA_SORTED_BY_DESC); const newestSortedList = postList.sort(.......);
개선된 코드다. 앞서 설명한 변수의 특성을 활용했다. 컴포넌트 내부의 변수는 렌더링이 될 떄마다 계산되는 값이기에 최신순 정렬 리스트를 변수로 할당하였다. 이로써 불필요한 렌더링 트리거를 예방하고 코드의 길이 또한 효율적으로 줄인다.
코드를 작성하다보면 컴포넌트의 전체적인 수명과 동일하게 지속된 정보를 일관적으로 제공해야하는 경우가 존재한다. 예를 들어 isLoading이나 isMounted와 같은 상태들이 그러하다. 일반적으로 이러한 상태를 위의 코드와 같이 useState를 사용하여 제공할 수 있다.
const [isLoading, setIsLoading] = useState(true); useEffect(() => { setIsLoading(false); }, [])
위의 코드는 useState와 useEffect를 사용하여 첫렌더링이 완료되면 isLoading 상태를 false로 변환한다. 그러나 해당 값을 state가 아닌 useRef를 사용하여 불필요한 리렌더링을 방지할 수 있다. 이를 적용하는 코드는 아래와 같다.
// Clean code const isLoading = useRef(false); useEffect(() => { isLoading.current = true; return () => { isLoading.current = false; } }, []);
해당 코드는 useState 대신 useRef를 통해 상태값을 줄여주었고, useEffect 내 클린업 함수를 통해 컴포넌트가 종료될 시 isLoading의 값을 다시 초기화 해주고 있다. 이처럼 렌더링 프로세스와 관계없이 가변적인 값을 다루고 싶을 경우 useRef를 사용할 수 있다.
상태값을 업데이트할 때 이전 값을 활용해야하는 경우가 있다. 대표적으로 카운트 함수가 그러하다.
const [count, setCount] = useState(10); const handleClick = () => { setCount(count + 1); // 10 + 1 setCount(count + 1); // 10 + 1 setCount(count + 1); // 10 + 1 }
위의 동작을 살펴보면 count의 상태값이 즉시 업데이트 되지 않는다. 간혹 이전값을 기반으로 상태를 업데이트 해야할 때 위의 코드처럼 작성하는 경우가 있다. 대부분의 코드에서 정상적으로 동작할 수 있지만, 위의 코드와 같이 동시에 여러 업데이트를 처리하는 경우라면 예기치 못한 오류들이 발생할 수 있다. 따라서 개발자의 의도대로 상태값을 관리하기 위해서는 다음과 같이 set함수에 업데이터 함수를 전달할 수 있다.
// Clean code const [count, setCount] = useState(10); const handleClick = () => { setCount(prev => prev + 1); // 10 + 1 setCount(prev => prev + 1); // 11 + 1 setCount(prev => prev + 1); // 12 + 1 }
리액트로 프로그래밍 하다보면, 서로 연관된 상태들을 관리해야하는 경우가 있다. 이 경우 useState 대신 useReducer를 사용하면 연관된 상태들을 구조화하는데 도움을 준다. 보다 선언적으로 코드를 리펙토링하고 싶을 때 useReducer를 사용할 수 있다. 예시 코드를 살펴보자.
function FetchingComponent = () => { const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [isError, setIsError] = useState(false); const fetchData = () => { setIsLoading(true); fetch(URL) .then(() => { setIsLoading(false); setIsSuccess(true); } ) .catch(() => { setIsLoading(false); setIsError(true); } ) } if (isLoading) return <LoadingComponent />; if (isError) return <ErrorComponent />; if (isSuccess) return <SuccessComponent />; }
위의 코드는 서버의 응답상태와 관련된 처리를 하고 있다. 총 세가지의 상태를 관리하고 있다. 아쉬운 점은 이 세가지 상태의 의존성이 존재한다는 것이다. 서버로부터 응답이 로딩중인지, 에러인지, 성공인지에 따라서 세 가지의 연관된 상태들은 모두 다른 값들을 가져야 한다. 이러한 의존성을 객체를 통해서도 선언적으로 표현할 수도 있지만, useReducer를 통해 구조화하여 선언적으로 표현 가능하다.
// Clean code const DEFAULT_STATE = { isLoading : false, isSuccess : false, isError : false, } const reducer = (state, action) => { switch(action.type) { case 'FETCH_LOADING' : return { isLoading: true, isSuccess: false, isError: false } case 'FETCH_SUCCESS' : return { isLoading: false, isSuccess: true, isError: false } case 'FETCH_ERROR' : return { isLoading: false, isSuccess: false, isError: true } default: return DEFAULT_STATE } } function FetchingComponent = () => { const [state, dispatch] = useReducer(reducer, DEFAULT_STATE); const fetchData = () => { dispatch({ type: 'FETCH_LOADING' }) fetch(URL) .then(() => { dispatch({ type: 'FETCH_SUCCESS' }) } ) .catch(() => { dispatch({ type: 'FETCH_ERROR' }) } ) } if (state.isLoading) return <LoadingComponent />; if (state.isError) return <ErrorComponent />; if (state.isSuccess) return <SuccessComponent />; }
위의 코드가 useReducer를 통해 의존성 있는 상태들을 구조화한 패턴이다. 코드의 양은 늘었지만, 보다 코드를 선언적인 스타일이 되었다.
이상으로 리액트 State를 보다 클린하게 작성하는 방법들을 알아보았다. State를 클린하게 다루는 핵심은 필요없는 상태값을 선언하지 않는 것이라 생각이 든다. 상태값을 여러가지로 선언한다는 것 자체가 잦은 리렌더링을 트리거할 수 있고 결과적으로 성능에 영향을 미치기 때문이다. 이 외에도 가능한 코드 자체를 선언적으로 작성하는 것이 중요하다고 느낀다. 이러한 핵심들을 바탕으로 언젠가 프로젝트들을 리팩토링하는 시간을 가져봐야겠다. 다음 포스팅에서는 리액트 Props에 대한 클린코드 포스팅을 진행해보려 한다!
Comments